security: validate contract inputs and bound registry metadata (WNS-03, WNS-09, WNS-10, WNS-11, WNS-12) - #551
Conversation
…3, WNS-09, WNS-10, WNS-11, WNS-12) The remaining input-validation findings from CertiK "Wire Network - Sysio Audit 1". No ABI changes — every fix is an added guard. [Major] roa::reducepolicy accepted negative NET/CPU/RAM weights. The action bounded the request only from above (`w <= stored`), a condition any negative amount satisfies whenever the stored weight is positive. The weight is then applied as a SUBTRACTION, so a negative one INCREASED the account's quota: `new_net = max(0, net_limit - net_weight.amount)`. A node owner could inflate an account's NET/CPU past the issuer's ROA budget — bypassing expandpolicy's free-allocation check — and desynchronise the reslimit row and the issuer's nodeowners accounting from the policy weights. addpolicy and expandpolicy both reject negatives already; reducepolicy was the outlier. CertiK's PoC (a 10.0000 SYS policy reduced by -5.0000 SYS ending at 15.0000 SYS) is now a regression test asserting the policy and the account's resource limits are untouched. [Minor] token::create stored an unvalidated issuer. `issuer` was trusted input after `require_auth(get_self())`, but `issue` gates on `to == st.issuer` + `require_auth(st.issuer)`. A null or non-existent issuer therefore produced a token nobody could ever issue while permanently burning the symbol, since `create` rejects duplicates. Now checks `issuer.value != 0` and `is_account(issuer)`, after the supply checks so existing error ordering holds. [Minor] Registry metadata was unbounded in system-paid state. `tokens::regtoken` moved `symbol_name` and `description` into a persisted row billed to `ram_payer = sysio` — the shared system pool — without bounding either, letting each unique code consume up to the KV/action ceiling. The identical unbounded pair, with the same billing, was on `chains::regchain` and `reserv::regreserve`, so the limits live once in a new shared header (`sysio.opp.common/registry_metadata.hpp`: 32-byte label, 256-byte description — the latter matching the established `token::issue` memo bound) and all three enforce them via `check_metadata` before emplace. `reserv::oncrtreserve` carries the same two strings from an outpost-side creator but is an OPP inbound dispatch handler, which must never abort: a check() there rolls back the consensus-tipping delivery and stalls epoch advancement chain-wide. It uses the non-throwing `metadata_exceeds_bounds` and joins the existing reject predicate, releasing the creator's escrow through the RESERVE_CREATE_CANCELLED flow already used for an invalid amount or an unlinked creator. The CANCELLED tombstone is itself a sysio-billed row, so its metadata is clamped via truncate_label/truncate_description — storing it verbatim would persist exactly the state the bound prevents. [Informational] regchain did not enforce the depot's canonical code. Bootstrap invariant V3 (docs/platform-bootstrap-config.md) is "exactly one CHAIN_KIND_WIRE chain, code WIRE". Only the cardinality half was on-chain, and `is_depot` is derived from the kind alone, so a registration could claim depot identity under any code and the code's validity rested entirely on the off-chain config validator. [Optimization] Removed the unused sysiosystem::system_contract forward declaration from sysio.token.hpp — it implied a sysio.token -> sysio.system dependency that does not exist. Verified: contracts_unit_test 588 cases, unit_test 1515 cases, plugin_test — all green, no errors. Change-Id: Ia903c97aace16597352dd18e9a892568f17e5433
huangminghuang
left a comment
There was a problem hiding this comment.
Two actionable findings from the delegated OCR review.
| // under any code (e.g. `FAKE`) -- previously only the cardinality half was on-chain | ||
| // and the code depended entirely on the off-chain config validator. | ||
| if (kind == opp::types::CHAIN_KIND_WIRE) { | ||
| sysio::check(code == WIRE_CHAIN_CODE, |
There was a problem hiding this comment.
[P2] Reserve the WIRE code bidirectionally
This guard only handles kind == CHAIN_KIND_WIRE. During bootstrap, regchain(CHAIN_KIND_EVM, "WIRE", ...) can still succeed first; code uniqueness then permanently prevents registering the canonical depot row, and there is no erase action. Enforce the inverse implication too: code == WIRE_CHAIN_CODE must require kind == CHAIN_KIND_WIRE, with a regression test for this ordering.
| inline std::string truncate_label(std::string label) { | ||
| if (label.size() > label_max_bytes) label.resize(label_max_bytes); | ||
| return label; | ||
| } | ||
|
|
||
| /// Clamp a description for storage on a reject-path tombstone row. See `truncate_label`. | ||
| inline std::string truncate_description(std::string description) { | ||
| if (description.size() > description_max_bytes) description.resize(description_max_bytes); |
There was a problem hiding this comment.
[P3] Preserve UTF-8 when truncating metadata
resize() truncates bytes rather than UTF-8 code points. A valid 33-byte label consisting of 31 ASCII bytes plus é is cut midway through the final character, persisting malformed human-readable metadata in the CANCELLED row. Clamp at a valid UTF-8 boundary or store a safe fixed/empty tombstone value, and add a multibyte boundary test.
…F-8 boundaries Addresses review on #551 (both findings). [P2] Reserve the `WIRE` code from every non-WIRE kind. The guard only enforced the forward implication (kind WIRE => code WIRE), so `regchain(CHAIN_KIND_EVM, "WIRE", ...)` still succeeded. Chain codes are unique and there is NO erase action, so such a row would permanently squat the depot's identity and leave the canonical self-row unregisterable — bricking bootstrap with no on-chain recovery. The inverse is now enforced too, with a distinct message so the two failures stay diagnosable. The regression test attempts the squat BEFORE the depot row exists, since the ordering is the whole point, then asserts the canonical row still registers. [P3] Clamp tombstone metadata on a UTF-8 code-point boundary. `resize()` cuts at a byte offset, not a character boundary: a 33-byte label of 31 ASCII bytes plus `é` (0xC3 0xA9) clamped to 32 kept a lone 0xC3 lead byte and persisted malformed text in state. `clamp_utf8` walks back off continuation bytes so a straddling character is dropped whole. The bound itself stays a BYTE bound — it exists to cap state size. Test asserts the stored label is the 31 ASCII bytes, not 32. Verified: contracts_unit_test 590 cases, no errors. Change-Id: Ibfd23d34b8b9220113761ad545017f8a2c011895
|
Both findings were real and are fixed in 537d436. Thanks — P2 in particular was a genuine gap, not just a hardening nit. [P2] Reserve the You're right that the forward implication alone left the hole open, and the consequence is worse than a mis-registration: chain codes are unique and there is no erase action, so an EVM/SVM row registered under
[P3] Preserve UTF-8 when truncating — confirmed and fixed. Your example is exactly what it did: 31 ASCII +
Verification: |
huangminghuang
left a comment
There was a problem hiding this comment.
One low-severity project-rule finding from the follow-up review.
| // character straddling the boundary is being split, so walk back onto its lead byte and | ||
| // drop the whole sequence. Terminates at 0 in the worst case. | ||
| std::size_t cut = max_bytes; | ||
| while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut; |
There was a problem hiding this comment.
[P3] Name the UTF-8 bit masks — The repository’s “No magic literals” rule requires nontrivial numeric values behind named constants. These masks define the correctness of the UTF-8 boundary check, so introduce named inline constexpr values for 0xC0 and 0x80 and use them here.
Addresses review on #551 (the UTF-8 bit-mask comment, by removing the code it refers to). The tombstone no longer carries the creator's over-bound strings at all: `name` becomes the fixed marker `<rejected>` and `description` is cleared. That deletes `clamp_utf8`, `truncate_label`, `truncate_description` and the boundary test along with the masks. Truncation was the wrong tool for this slot. It has to cut at a byte offset rather than a UTF-8 code-point boundary, so it needs the boundary walk purely to avoid corrupting its own input — and the text it salvages buys nothing: nothing reads a tombstone's metadata (the reclaim path overwrites every field) and the creator's originals are preserved in the inbound OPP envelope artifact regardless. A fixed marker also states plainly that the row was rejected rather than leaving a blank a reader has to interpret. Worth recording for anyone revisiting this: the bound remains a state-size control, NOT a UTF-8 guarantee. `check_metadata` measures bytes only, so malformed input under the bound still reaches state verbatim — and that is harmless, because `fc::json::escape_string` already calls `prune_invalid_utf8` before emitting, so no malformed byte reaches a client through get_table_rows. The substitution is conditional on `oversized_metadata`. The other two reject reasons on that shared predicate — an unlinked creator and an invalid amount — carry perfectly valid, in-bounds metadata, so they keep it exactly as before. The regression test now covers both an ASCII and a multibyte over-bound label: with nothing truncated there is no code-point boundary left to split. Verified: contracts_unit_test 589 cases, no errors. Change-Id: I037004f1f8b8d6a00ea436d69b32dde6fb0b945d
|
Resolved in 06c9473, but by deleting the code rather than naming the masks — the mask comment is correct, and prompted a look at whether the boundary walk was earning its place. It wasn't. The tombstone no longer carries the over-bound strings at all. Two things that made truncation the wrong tool for this slot:
One correctness point worth flagging, since it isn't visible from the diff alone: the substitution is conditional on The regression test now covers an ASCII and a multibyte over-bound label, which is your multibyte case answered from the other direction: with nothing truncated there is no code-point boundary left to split. Verification: Separately, and unrelated to this change — on one full-suite run I saw |
|
Correction to my note above: the intermittent failure is not related to WNS-07 / WIRE-321. That guess was wrong, and the real cause is unrelated to this PR. Running it down properly — master baselined at 580 cases, this branch at 589 — the failures turned out to be a wall-clock budget in the test harness, not a logic defect: // libraries/testing/tester.cpp:123
const fc::microseconds base_tester::abi_serializer_max_time{1000*1000}; // 1s for slow test machinesThat accounts for every observation. The failing test wanders across unrelated suites run to run — Measured tally: 3 failures across 13 full runs on this branch, 0 across 6 on master. I'd caution against reading the master number as a clean bill of health — at the observed ~23% rate, P(0 failures in 6) ≈ 0.21, so master's baseline is compatible with the same flakiness rather than distinguishable from it. The exception text is what settles this, not the counts. The one part genuinely attributable here: this PR adds nine test cases, so each full run does marginally more work and has marginally more exposure to the deadline. That is a magnitude nudge on a pre-existing harness limit, not something introduced by the change. |
huangminghuang
left a comment
There was a problem hiding this comment.
One low-severity test-coverage finding from the latest follow-up review.
| ("token_code", codename_mvo("ETH")) | ||
| ("reserve_code", codename_mvo(reserve_code)) | ||
| ("name", name) | ||
| ("description", "") |
There was a problem hiding this comment.
[P3] Cover the oversized-description path — This regression test always passes an empty description, so it never exercises the description.size() > description_max_bytes half of metadata_exceeds_bounds or verifies that an over-bound description cannot be persisted. Add a description-only case with an in-bound name and a 257-byte description, then assert CANCELLED status, the <rejected> marker, and an empty stored description.
…ames The e2e gate caught this: run 31258456527 came back 6/13, and two of the seven failures are mine. `flow-reserve-lifecycle` and `flow-swap-private-reserves` create reserves through `create_reserve` -> `oncrtreserve`, and four of their labels are longer than the 32 bytes I picked: ETHEREUM-ETH/WIRE unlinked-creator reserve 42 SOLANA-USDCSOL/WIRE private reserve 35 ETHEREUM-ETH/WIRE private reserve 33 (x2) Over-bound metadata routes into the CANCELLED/refund path, so the reserve those flows expect PENDING came back CANCELLED. 32 was wrong, not the names. WNS-10 is about UNBOUNDED strings consuming up to the KV/action ceiling of system-paid state; it says nothing about how terse a label should be, and a reserve naming its full leg is exactly what a reader wants to see in the registry. The bound is now 128 — still a real cap, with room for descriptive names rather than sized to the current longest. The header says so explicitly, so the next person hitting it questions the bound before shortening a legitimate label. The 256-byte description bound is unchanged and unaffected. Verified against the actual data this time rather than a sample: every `*Name`/`*Description` literal across all wire-tools-ts packages now fits, with the longest label at 42 bytes (3x headroom) and the longest description at 137. My earlier check looked only at `RegistrySteps.ts` — the bootstrap registrations, max 23 bytes — and concluded the harness fit. It did not cover the flow-level `create_reserve` names, which is precisely where this broke. Tests rebased off the old boundary: the over-bound cases (33 -> 129 bytes), the inclusive at-limit assertions (32 -> 128), the error-text expectations, and the multibyte UTF-8 case (31 ASCII + `é` -> 127 ASCII + `é`). contracts_unit_test: 589 cases, no errors. Change-Id: I86784a8f866e94d540f63f8a59ee9030d18b75db
|
The e2e gate found a real bug in this PR, and it was mine. Fixed in 119dcc7. Run 31258456527 came back 6/13. Two of the seven failures are attributable to this branch:
Over-bound metadata routes into the CANCELLED/refund path, so the reserve those flows expect 32 was wrong, not the names. WNS-10 is about unbounded strings consuming up to the KV/action ceiling of system-paid state — it says nothing about how terse a label should be, and a reserve naming its full leg is exactly what a reader wants in the registry. The bound is now 128: still a real cap, sized with room for descriptive names rather than to the current longest. The header comment says so explicitly, so the next person who hits it questions the bound instead of shortening a legitimate label. The 256-byte On the verification I claimed earlier. I previously wrote that harness metadata "fits comfortably" — I had checked Worth noting for later: the description bound now has materially less headroom than the label bound. 256 came from the Tests rebased off the old boundary — over-bound cases 33 → 129 bytes, inclusive at-limit assertions 32 → 128, the error-text expectations, and the multibyte UTF-8 case (31 ASCII +
The other five failures
|
E2E gate green — 31390895605, 13/13The two flows the 32-byte label bound broke now pass: That closes the gap reported earlier: run 31258456527 was 6/13, of which two failures were this branch's (over-bound reserve labels) and five were #550's fee change awaiting the wire-tools-ts fix. This run does not gate this PR alone. It carries wire-tools-ts#59, and it had to:
|
`oncrtreserve_oversized_metadata_is_cancelled` drove only over-bound labels; every case passed an empty description, so `metadata_exceeds_bounds`' second disjunct was never evaluated and nothing asserted that an over-bound description stays out of state. Adds a third case: an in-bound name with a 257-byte description (one over `description_max_bytes`), so the description is the sole rejection reason. Asserts the row lands CANCELLED, the name reads the `<rejected>` marker, and the stored description is empty. The per-case helper now takes the description explicitly rather than pinning it to "", which is what limited the existing cases to the label half. Change-Id: Id84800a46cadcada31e36986794ddf1e41078dc3
|
All four threads addressed. [P3] Cover the oversized-description path — fixed in [P2] Reserve the [P3] Preserve UTF-8 when truncating metadata — obsoleted by [P3] Name the UTF-8 bit masks — moot under the same commit: the Verification: |
huangminghuang
left a comment
There was a problem hiding this comment.
Approved — the code review is clean.
Before merge, please refresh the PR description so it matches the final implementation: label_max_bytes is 128 (not 32); oversized tombstones now store <rejected> and clear the description (the truncation helpers were removed); the compatibility note should reflect the 42-byte real labels/e2e follow-up; and the verification counts should be updated for the final head.
The remaining input-validation findings from CertiK "Wire Network - Sysio Audit 1", closing WIRE-318, WIRE-323, WIRE-324, WIRE-325 and WIRE-326.
No ABI changes — every fix is an added guard, so no
SysioContractTypes.tsregeneration and no downstreamwire-libraries-ts/wire-tools-tsrebuild is required. Only the five.wasmartifacts changed; no.abidid.roa::reducepolicyaccepts negative weights and expands quotastoken::createstores the issuer without validating ittokens::regtokendoes not boundsymbol_name/descriptionregchaindoes not enforce the canonicalWIREcodesysiosystem::system_contractforward declarationWNS-03 — negative reduction weights inflate quota
reducepolicybounded the request only from above (w <= stored), which any negative amount satisfies whenever the stored weight is positive. The weight is then applied as a subtraction, so a negative one increased the account's quota:A node owner could inflate an account's NET/CPU past the issuer's ROA budget — bypassing
expandpolicy's free-allocation check — and desynchronise thereslimitrow and the issuer'snodeownersaccounting from the policy weights.addpolicyandexpandpolicyboth reject negatives already;reducepolicywas the outlier.CertiK's PoC (a 10.0000 SYS policy reduced by −5.0000 SYS ending at 15.0000 SYS) is now
reducepolicy_negative_weight_rejected, covering all three weights and asserting the policy and the account's on-chain resource limits are untouched after the rejected attempts.WNS-09 — unvalidated issuer
issuerwas trusted input afterrequire_auth(get_self()), butissuegates onto == st.issuer+require_auth(st.issuer). A null or non-existent issuer produced a token nobody could ever issue while permanently burning the symbol, sincecreaterejects duplicates. Now checksissuer.value != 0andis_account(issuer), placed after the supply checks so existing error ordering is preserved.WNS-10 — unbounded metadata in system-paid state
CertiK raised this on
tokens::regtoken, but the identical unboundedname/descriptionpair, with the sameram_payer = sysiobilling, was also onchains::regchainandreserv::regreserve. Rather than fix one instance of a three-instance defect, the limits live once in a new shared header:contracts/sysio.opp.common/include/sysio.opp.common/registry_metadata.hpp—label_max_bytes = 128,description_max_bytes = 256(the latter matching the establishedtoken::issuememo bound).All three privileged registrations call
check_metadata()beforeemplace.The label bound started at 32 and was wrong
The e2e gate caught it: run 31258456527 came back 6/13, two failures mine.
flow-reserve-lifecycleandflow-swap-private-reservescreate reserves throughcreate_reserve→oncrtreserve, and four of their labels exceed 32 bytes (longest 42). Over-bound metadata routes into the CANCELLED/refund path, so a reserve those flows expect PENDING came back CANCELLED.32 was wrong, not the names. WNS-10 is about unbounded strings consuming up to the KV/action ceiling of system-paid state; it says nothing about how terse a label should be, and a reserve naming its full leg is what a reader wants in the registry. The bound is 128 — still a real cap, with room for descriptive names rather than sized to the current longest. The header says so explicitly, so the next person who hits it questions the bound before shortening a legitimate label.
reserv::oncrtreserveneeded different handlingIt carries the same two strings from an outpost-side creator, but it is an OPP inbound dispatch handler — a
check()there rolls back the consensus-tipping delivery and stalls epoch advancement chain-wide (feedback_opp_handlers_never_throw,epoch-stall-is-fatal). It instead uses the non-throwingmetadata_exceeds_bounds()and joins the existing reject predicate alongsideinvalid_amountand the unlinked-creator case, releasing the creator's escrow through theRESERVE_CREATE_CANCELLEDflow that path already emits.The CANCELLED tombstone is itself a
sysio-billed row storingname/description, so writing the oversized strings onto it would persist exactly the state the bound prevents. The rejected row stores a fixed<rejected>marker as its name and an empty description — nothing is truncated. Truncation would have had to cut at a byte offset rather than a UTF-8 code-point boundary, and the salvaged text buys nothing: nothing reads a tombstone's metadata (the reclaim path overwrites every field) and the creator's originals are preserved in the inbound OPP envelope artifact regardless. The earliertruncate_label/truncate_descriptionhelpers were removed.oncrtreserve_oversized_metadata_is_cancelledcovers three cases — an over-bound ASCII label (129 bytes), an over-bound multibyte label (127 ASCII +é= 129 bytes, which a byte-wise clamp would have split), and an over-bound description (257 bytes) behind an in-bound name so each half ofmetadata_exceeds_boundsis driven independently. All three assert CANCELLED, the<rejected>marker, and — for the description case — an empty stored description.The header makes the split explicit: privileged abort-safe registrations
check; dispatch handlers ask and route.WNS-11 — depot identity not pinned to its code
Bootstrap invariant V3 (
docs/platform-bootstrap-config.md) is "exactly oneCHAIN_KIND_WIREchain, codeWIRE". Only the cardinality half was enforced on-chain, andis_depotis derived from the kind alone — so a registration could claim depot identity under any code (FAKE), with the code's validity resting entirely on the off-chain config validator.The guard is bidirectional:
kind == CHAIN_KIND_WIRErequirescode == "WIRE", and theelsebranch rejectscode == "WIRE"under any other kind. The forward check alone still admittedregchain(EVM, "WIRE", ...), which — with code uniqueness and no erase action — would have permanently bricked registration of the canonical depot row. Both orderings are covered insysio.epoch_tests.cpp.WNS-12
Removed the unused
sysiosystem::system_contractforward declaration fromsysio.token.hpp; it implied asysio.token→sysio.systemdependency that does not exist.Verification
Re-run at the final head (
f8ba67a):contracts_unit_test(full,--sys-vm)unit_test(full,--sys-vm)plugin_testunit_testmatters here beyond the usual sweep:unittests/test_contracts.hpp.inloadssysio.token.wasmfrom the same build path, so the newis_account(issuer)check is live in those tests too. Everytoken::createcall site inunittests/usesaliceorsysio.token, both created in their fixtures.Harness compatibility was re-verified against the actual data rather than a sample: every
*Name/*Descriptionliteral across allwire-tools-tspackages fits, with the longest label at 42 bytes (3× headroom under 128) and the longest description at 137. The original check looked only atRegistrySteps.ts— the bootstrap registrations, max 23 bytes — and missed the flow-levelcreate_reservenames, which is exactly where the 32-byte bound broke.Reviewer note
The WNS-10 fix extends past CertiK's literal
regtokenscope to the three sibling registries plus theoncrtreservedispatch path. That was a deliberate call — same defect, same billing account — but it is the one part of this PR that is wider than the finding, so it is the part worth a second opinion.